一覧に戻る

MapLibre GL JSと地理院標高タイルで3D地形を表示する

#JavaScript#TypeScript#GIS#foss4g#MapLibre

2024-06-30追記

https://github.com/mug-jp/maplibre-gl-gsi-terrain

本記事の内容は上記リポジトリにて公開されているライブラリに統合されています。簡単に利用できます。

TL;DR

https://jsbin.com/vonixulezo/edit?html,output

  • MapLibre GL JSのv2.2.0-pre.2で3D-Terrainが実装された
  • MapLibre GL JSのaddProtocol()を使う事で読み込んだタイルデータを加工できる
  • addProtocol()で地理院標高タイルをTerrainRGBへ変換することで、標高タイルを使って3D-Terrainを表示できた

MapLibre GL JSと3D-Terrain

https://github.com/maplibre/maplibre-gl-js/pull/1022

3D-Terrainの実装はかなり長い時間がかけられており、早い段階からデモを触ったりしてワクワクしていたものでした。 それが最近マージされ、2.2.0-pre.2でプレリリースとして公開されました。

必要なデータ

DEMタイルが必要で、TerrainRGB(もしくはTerrarium)でエンコードされた画像である必要があります。 一方で地理院標高タイルはこのいずれでもない特殊なエンコーディングです。

標高エンコーディングの考え方については下記を参照 https://qiita.com/Kanahiro/items/e22594b738655a189c1d#rgb%E5%80%A4%E3%81%AE%E6%A8%99%E9%AB%98%E6%8F%9B%E7%AE%97

MapLibre(Mapbox) GL JSと標高タイル

地理院タイルは標高エンコーディングが独特であるため、MapLibreで素直に利用する事ができません。 それでもなお標高タイルをMapLibre(Mapbox) GL JSで使おうとした先行事例がありました。

先行事例

https://qiita.com/tattii88/items/3d88907c7116d1aeb028 Mapbox GL JS自体をフォークして、標高タイルのエンコーディングを実装しています。

https://qiita.com/frogcat/items/d12bed4e930b83eb3544 ServiceWorkerfetch()を監視して標高タイル画像をTerrainRGBに変換しています。

https://qiita.com/T-ubu/items/c35023e1df2362bd8e7f v2.2.0-pre.2で標高タイルをそのまま使って3D-Terrainを表示しています。

しかし最近MapLibreにはaddProtocol()という関数が実装されました。これを用いれば、もっとラクに標高タイルをTerrainRGBに変換する事ができます。

addProtocol()

この関数は名前からは使い道がわかりにくいのですが、要はカスタムしたsourceを定義する事ができます。 ここでいうプロトコルとは、sample://で言うsampleです。タイルURLでこのような特定のプロトコルを接頭辞とすることで、タイルへのリクエストなどをaddProtocol()で定義したとおりの振る舞いとする事ができます。

標高タイルをTerrainRGBに変換する

MapLibreのMapインスタンスを初期化します。なお標高タイル(gsidem)のtilesの書き方に注意してください(gsidem://がプロトコル)。

import maplibregl, { Map } from 'maplibre-gl';

new Map({
    container: 'map',
    maxPitch: 85,
    style: {
        version: 8,
        sources: {
            gsi: {
                type: 'raster',
                tiles: [
                    'https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg',
                ],
                attribution: '地理院タイル',
            },
            gsidem: {
                type: 'raster-dem',
                tiles: [
                    'gsidem://https://cyberjapandata.gsi.go.jp/xyz/dem_png/{z}/{x}/{y}.png',
                ],
                tileSize: 256,
                maxzoom: 14,
            },
        },
        layers: [
            {
                id: 'gsi',
                type: 'raster',
                source: 'gsi',
            },
        ],
        terrain: {
            source: 'gsidem',
            exaggeration: 1.2,
        },
    },
});

addProtocol()で振る舞いを定義します。

const gsidem2terrainrgb = (r, g, b): number[] => {
    let height = r * 655.36 + g * 2.56 + b * 0.01;
    if (r === 128 && g === 0 && b === 0) {
        height = 0;
    } else if (r >= 128) {
        height -= 167772.16;
    }
    height += 100000;
    height *= 10;
    const tB = (height / 256 - Math.floor(height / 256)) * 256;
    const tG =
        (Math.floor(height / 256) / 256 -
            Math.floor(Math.floor(height / 256) / 256)) *
        256;
    const tR =
        (Math.floor(Math.floor(height / 256) / 256) / 256 -
            Math.floor(Math.floor(Math.floor(height / 256) / 256) / 256)) *
        256;
    return [tR, tG, tB];
};

maplibregl.addProtocol('gsidem', (params, callback) => {
    const image = new Image();
    image.crossOrigin = '';
    image.onload = () => {
        const canvas = document.createElement('canvas');
        canvas.width = image.width;
        canvas.height = image.height;

        const context = canvas.getContext('2d');
        context.drawImage(image, 0, 0);
        const imageData = context.getImageData(
            0,
            0,
            canvas.width,
            canvas.height,
        );
        for (let i = 0; i < imageData.data.length / 4; i++) {
            const tRGB = gsidem2terrainrgb(
                imageData.data[i * 4],
                imageData.data[i * 4 + 1],
                imageData.data[i * 4 + 2],
            );
            imageData.data[i * 4] = tRGB[0];
            imageData.data[i * 4 + 1] = tRGB[1];
            imageData.data[i * 4 + 2] = tRGB[2];
        }
        context.putImageData(imageData, 0, 0);
        canvas.toBlob((blob) =>
            blob.arrayBuffer().then((arr) => callback(null, arr, null, null)), // ここで返すデータは、画像のArrayBuffer()でなければならない
        );
    };
    image.src = params.url.replace('gsidem://', '');
    return { cancel: () => {} };
});

すると冒頭のスクリーンショットのように3D-Terrainを表示する事ができます。

所感

  • 画像の変換が入るからと言ってパフォーマンスが明らかに悪くなる、とかはありませんでした。
    • ImageDataをPNGに変換する際、上記例ではcanvasの機能を使っているが、fast-pngを使った方が速いかもしれない。
    • 標高タイルからTerrainRGBへの変換は、先行事例にあった関数の方がぱっと見で速そうに見える。
  • addProtocol()によりタイルデータを改変出来るようになったので使い道は無限大。ちなみにこの関数はMapboxでは実装されていない。
    • type: 'custom'のレイヤーでタイルデータが読めるようになったらうれしいなぁ…。